Skip to content

Ask Composer's autoloaders for the file instead of letting them include it - #6431

Open
theodorejb wants to merge 1 commit into
phpstan:2.2.xfrom
theodorejb:fix-gh-15184
Open

Ask Composer's autoloaders for the file instead of letting them include it#6431
theodorejb wants to merge 1 commit into
phpstan:2.2.xfrom
theodorejb:fix-gh-15184

Conversation

@theodorejb

@theodorejb theodorejb commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

AutoloadSourceLocator finds which file declares a class by running the registered autoloaders behind FileReadTrapStreamWrapper, which records the path an include reached for and serves an empty script in its place. That only shadows the real file while the compiler asks the wrapper for the contents. With OPcache already holding the script it does not ask, and the file runs a second time:

Child process error (exit code 255): Fatal error: Cannot redeclare
function Psl\Type\optional() ...

That is fatal for a file declaring a function, which is the function-per-file layout of php-standard-library and azjezz/psl: their files-autoload bootstrap has already loaded every path their PSR-4 prefix also resolves to. The probe itself is legitimate — analysed code doing use Psl\Type; and calling Type\optional(...) makes PHPStan check whether that name is also a class.

2.2.13 exposed this by force-enabling OPcache in spawned workers (TurboProcessRestarter::resolveOpcacheArgs()). It already handles the opposite direction — servesParseError() and the opcache_invalidate() loop stop the trap's empty script from being cached and shadowing the real file — but the cache-hit bypass was missed.

Fix

ClassLoader::findFile() answers with the same path loadClass() would include, without running anything. Asking it, rather than arranging for the include to be harmless, means nothing is compiled and no cache is consulted — which is what makes this hold wherever PHP runs.

FileReadTrapStreamWrapper is untouched.

Ordering

Autoloaders still run in registration order. Every non-Composer autoloader runs inside the trap as before, one at a time, since an autoloader ahead of Composer's may claim a name Composer would resolve elsewhere.

Running them one at a time matters in practice: hoa/compiler registers an autoloader that sits ahead of the analysed project's loader, and an earlier revision of this patch — which gave up on the fast path at the first non-Composer autoloader — would therefore almost never have taken it.

Path resolution

findFile() concatenates the mapped prefix with the rest of the name, so its answer can carry ../ segments and mixed separators, where PHP resolves an include path before the trap ever sees it. It is resolved here to match, which locateIdentifier() relies on when it compares the located path against ReflectionClass::getFileName() to tell two same-named classes in one file apart. AutoloadSourceLocatorTest covers exactly that case and catches the difference.

On the earlier revisions

The first two versions of this PR kept the include and tried to make it safe — dropping the shared memory entry with opcache_invalidate(), then refusing the open for a file already in get_included_files(). Both passed on Windows and failed the Linux integration job, which is why the branch was force-pushed twice.

Both depend on OPcache consulting the wrapper at all, which is not something the engine promises. Asking findFile() depends on nothing of the sort: if the autoloader is never called, the file cannot be included.

Test

FileReadTrapStreamWrapperTest::testTrapSurvivesOpcacheCacheHit(), in the exec group. The failure is a fatal error and OPcache is only on in spawned processes, so it drives the real AutoloadSourceLocator in a subprocess under the worker's own OPcache flags, against a ClassLoader whose PSR-4 prefix resolves to an already-loaded file. Without the fix it reproduces the reported stack, ClassLoader->loadClass() inside withStreamWrapperOverride(). It covers:

  • a name whose prefix resolves to a file the process already ran, which must not run it again;
  • a name whose file nothing has loaded, which must still resolve without executing it.

Checked with OPcache on, with OPcache off, and against the pre-fix code.

What this does not cover

An autoloader that is not a Composer\Autoload\ClassLoader still goes through the trap, and is still exposed to the same OPcache behaviour. That includes wrapped loaders — Symfony's DebugClassLoader is not a ClassLoader instance even though it delegates to one.

That exposure is pre-existing rather than introduced here, and closing it would mean solving the original problem: keeping an include from running a file that is already in the opcode cache, without the engine promising to consult the stream wrapper.

Closes phpstan/phpstan#15184

🤖 Generated with Claude Code

@theodorejb theodorejb changed the title Evict the OPcache entry for a trapped path before the autoloader includes it Refuse the trap's open for a file the process has already run Sep 13, 2026
@theodorejb
theodorejb marked this pull request as draft September 13, 2026 03:57
@theodorejb theodorejb changed the title Refuse the trap's open for a file the process has already run Ask Composer's autoloaders for the file instead of letting them include it Sep 13, 2026
…de it

AutoloadSourceLocator finds which file declares a class by running the
registered autoloaders behind FileReadTrapStreamWrapper, which records the path
an include reached for and serves an empty script in its place. That only
shadows the real file while the compiler asks the wrapper for the contents.
With OPcache already holding the script it does not ask, and the file runs a
second time - fatal for one declaring a function, which is the function-per-file
layout of php-standard-library and azjezz/psl: their files-autoload bootstrap
has already loaded every path their PSR-4 prefix also resolves to.

ClassLoader::findFile() answers with the same path loadClass() would include,
without running anything, so ask it rather than arranging for the include to be
harmless. Nothing is compiled and no cache is consulted, which is what makes
this hold wherever PHP runs.

Autoloaders still run in registration order: every non-Composer one runs inside
the trap as before, one at a time, since an autoloader ahead of Composer's may
claim a name Composer would resolve elsewhere. Running them one at a time also
stops hoa/compiler's autoloader, registered ahead of the analysed project's
loader, from forcing the whole probe down the include path.

findFile() concatenates the mapped prefix with the rest of the name, so its
answer can carry ../ segments and mixed separators, where PHP resolves an
include path before the trap ever sees it. It is resolved here to match, which
locateIdentifier() relies on when it compares the located path against
ReflectionClass::getFileName() to tell two same-named classes in one file apart.

The test drives the real locator in a subprocess under the worker's own OPcache
flags: a name whose PSR-4 prefix resolves to a file the process already ran,
which must not run it again, and one whose file nothing has loaded, which must
still resolve without executing.

Closes phpstan/phpstan#15184

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@theodorejb
theodorejb marked this pull request as ready for review September 13, 2026 04:39
@phpstan-bot

Copy link
Copy Markdown
Collaborator

This pull request has been marked as ready for review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants